Add Nutrient Web SDK demo: full host-app shell around the viewer#193
Add Nutrient Web SDK demo: full host-app shell around the viewer#193MahmoudElsayad wants to merge 5 commits into
Conversation
A complete, standalone Vite + React + TypeScript app that showcases a fully custom UI around the Web SDK: custom top toolbar, draggable ink and text toolbars, side file explorer with drag-and-drop PDF uploads, a four-tab signature dialog, drag-and-drop form field placement, and a custom Form Creator property editor mounted into the SDK's slot. The SDK is loaded from the CDN via a script tag (pspdfkit-web@1.15.0), so no pspdfkit-lib copy step is required. biome.json is updated to opt the new example out of repo-wide formatting, matching the convention of every other example under web/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@sashamilenkovic @sc0 This is ready for you. |
| const Rect = sdk.Geometry?.Rect ?? sdk.Rect | ||
| if (!ImageAnnotation || !Rect) { | ||
| console.warn('SDK ImageAnnotation/Rect not found — signature not inserted.') | ||
| return |
There was a problem hiding this comment.
This return exits insertSignatureIntoSelected without setSigningModal(null), so when the SDK's ImageAnnotation/Rect constructors aren't found the signing modal stays open with no feedback — and re-clicking Insert keeps re-failing. Every other exit (!instance at 291, success/catch at 326) clears it. Move the close into a finally.
| const attachmentId = instance.createAttachment | ||
| ? await instance.createAttachment(blob) | ||
| : null |
There was a problem hiding this comment.
When createAttachment is absent, attachmentId is null but the code still builds new ImageAnnotation({ imageAttachmentId: null, … }) and creates it — producing a blank/throwing signature rather than failing fast. Bail with a visible message when attachmentId is null instead of flowing it downstream.
| useEffect(() => { | ||
| return () => { | ||
| files.forEach((f) => { | ||
| if (!f.isBuiltin) URL.revokeObjectURL(f.url) | ||
| }) | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []) |
There was a problem hiding this comment.
The empty dep array makes this cleanup close over the first-render files ([BUILTIN_FILE]), so blob URLs created for later-uploaded files are never revoked on unmount — the exhaustive-deps disable is hiding exactly that stale closure. Track files in a ref and revoke ref.current here, or drop the effect since it currently does nothing useful.
|
|
||
| if (!formField) { | ||
| console.warn('SDK FormField constructor not found for field type:', type) | ||
| await instance.create(widget) |
There was a problem hiding this comment.
This fallback creates a widget carrying a formFieldName but no backing form field. That orphan is what later breaks renameField (it renames the widget to a name no field has) and leaves a non-functioning field on the page. Consider not creating the widget at all when the FormField constructor is missing, or surfacing the failure.
| } | ||
|
|
||
| setPageIndex(instance.viewState.currentPageIndex) | ||
| setPageCount(instance.totalPageCount) |
There was a problem hiding this comment.
pageCount is set once here and only viewState.change/zoom.change are subscribed — nothing re-reads instance.totalPageCount after applyOperations add/remove/move. So the "of N" readout, the move-form validation, and the pageCount <= 1 delete-guard all go stale until reload (add pages → count stays old; delete toward 1 → guard checks a stale count). Re-read totalPageCount after each operation.
| const list = await instance.getAnnotations(pageIndex) | ||
| const inkIds = list | ||
| .toArray() | ||
| .filter((a) => a.constructor?.name === 'InkAnnotation') |
There was a problem hiding this comment.
Filtering by a.constructor?.name === 'InkAnnotation' relies on class names surviving the minified CDN bundle — if they're mangled, "delete drawings on page" silently matches nothing. instanceof window.NutrientViewer.Annotations.InkAnnotation is robust regardless. Same pattern in text/TextToolbar.tsx:206. Worth confirming against the 1.15.0 CDN build.
| if (formField?.set) updates.push(formField.set('name', nextName)) | ||
| if (annotation.set) updates.push(annotation.set('formFieldName', nextName)) |
There was a problem hiding this comment.
This rename isn't atomic across the widget↔form-field pair: if the form field can't be resolved (e.g. a widget created via the placeFieldAt fallback with no backing field), only annotation.set('formFieldName', …) runs and the widget desyncs from any field, with no rollback. deleteField (line 236) has the sibling issue — it falls back to annotation.id, deleting the widget and orphaning the FormField.
| delete: (idOrList: unknown) => Promise<unknown> | ||
| update: (objectOrList: unknown) => Promise<unknown> | ||
| createAttachment: (blob: Blob) => Promise<string> | ||
| getFormFields?: () => Promise<{ |
There was a problem hiding this comment.
getFormFields is an always-present core SDK method but is typed optional, so instance.getFormFields?.() short-circuits to null → getFormFieldForAnnotation returns [] → deleteField/renameField silently take the orphan/desync paths. Making it required turns a genuinely-missing API into a caught throw instead of document corruption.
| @@ -0,0 +1,21 @@ | |||
| { | |||
| "compilerOptions": { | |||
There was a problem hiding this comment.
The build script is tsc -b && vite build, and the root tsconfig uses project references, but neither leaf config sets "composite": true. TS build mode errors TS6306 ("Referenced project must have setting composite: true") when references lack it. I couldn't run tsc here — please confirm npm run build passes; if it errors TS6306, add "composite": true to both leaf compilerOptions (allowed alongside noEmit on TS 5.7). Same for tsconfig.node.json.
| // selection (Slate transforms) when in EDITING mode, or the annotation | ||
| // model when only SELECTED. Each handler is the user's explicit intent — | ||
| // we only push the property they actually changed. | ||
| const applyColor = useCallback( |
There was a problem hiding this comment.
applyColor/applyFontSize/applyBold/applyItalic repeat an identical guard preamble (instance → instance.annotations?.text → hasSingleTextAnnotationSelected → safeCall); only the final call differs. Extracting a withTextApi(fn) helper collapses the four bodies with no behavior change.
ritz078
left a comment
There was a problem hiding this comment.
Two items with no single line to anchor to:
web/viewer/README.mdnot updated.AGENTS.md(Runnable Local Examples) requires updating the nearest category README so a new example is discoverable; the catalog currently lists onlymulti-tab.- No CI builds or typechecks this example —
typecheck.ymlis scoped toplayground/**and the demo sits inbiome.json's ignore list, sotsc -b && vite buildisn't exercised in CI. Matches repo norms (multi-tab is the same); just verify the build locally before merge.
| }), | ||
| }) | ||
| .then((instance) => { | ||
| if (cancelled) { |
There was a problem hiding this comment.
This sdk.unload(container) (and the one in the cleanup return) unloads the shared container node, not the instance this run created. Under React 18 dev StrictMode the effect runs mount→cleanup→mount on the same container, so when this load() resolves late it can tear down the instance the second mount just created — blank viewer in npm run dev. (Production file-switches remount via key, so they're unaffected.) Scope the unload to the instance this effect loaded — resolve into a local and unload that — rather than container.
| } | ||
|
|
||
| /** | ||
| * `instance.setSelectedAnnotations` expects either an Immutable.List of ids or |
There was a problem hiding this comment.
This isn't accurate: setSelectedAnnotations always takes a NutrientViewer.Immutable.List of annotations/ids (AnnotationSelectionMixin) — there's no plain-array variant “depending on SDK build.” The runtime array fallback is defensive, not a documented contract. Worth correcting since this helper gets copied verbatim.
| @@ -0,0 +1,42 @@ | |||
| /** | |||
| * `instance.getSelectedAnnotations()` doesn't have a single shape across SDK | |||
There was a problem hiding this comment.
The payload shapes described here don't match the SDK: selection arrives via annotationSelection.change as an Immutable.List of annotations, and viewState.change carries a ViewState — never { annotation }. The defensive normalization is fine to keep, but the comment describes shapes the SDK doesn't emit and will mislead anyone copying this helper.
| if (!target) return | ||
| await instance.delete(target) | ||
| } catch (error) { | ||
| console.error('Failed to delete field', error) |
There was a problem hiding this comment.
deleteField/renameField swallow SDK failures to console.error with no user feedback — the user clicks delete/rename, it silently fails, document unchanged. The rest of the demo surfaces failures (Toolbar's operationError strip, App's window.alert); route these through one of those. (The delete itself is correct — deleting the form field cascades to its widget annotations.)
| const updates: unknown[] = [] | ||
| const formField = await getFormFieldForAnnotation(instance, annotation) | ||
|
|
||
| if (!formField?.set || !annotation.set) { | ||
| console.warn('No backing form field found; skipping partial field rename.') | ||
| return | ||
| } | ||
|
|
||
| updates.push(formField.set('name', nextName)) | ||
| updates.push(annotation.set('formFieldName', nextName)) | ||
|
|
||
| if (updates.length > 0) await instance.update(updates) |
There was a problem hiding this comment.
The updates accumulator is dead: the early return guarantees both .set calls run, so two items are always pushed and updates.length > 0 is never false.
| const updates: unknown[] = [] | |
| const formField = await getFormFieldForAnnotation(instance, annotation) | |
| if (!formField?.set || !annotation.set) { | |
| console.warn('No backing form field found; skipping partial field rename.') | |
| return | |
| } | |
| updates.push(formField.set('name', nextName)) | |
| updates.push(annotation.set('formFieldName', nextName)) | |
| if (updates.length > 0) await instance.update(updates) | |
| const formField = await getFormFieldForAnnotation(instance, annotation) | |
| if (!formField?.set || !annotation.set) { | |
| console.warn('No backing form field found; skipping partial field rename.') | |
| return | |
| } | |
| await instance.update([ | |
| formField.set('name', nextName), | |
| annotation.set('formFieldName', nextName), | |
| ]) |
| } | ||
|
|
||
| function writeJSON(key: string, value: unknown) { | ||
| localStorage.setItem(key, JSON.stringify(value)) |
There was a problem hiding this comment.
writeJSON is unguarded while readJSON catches. Signature data-URLs are large base64 PNGs, so setItem realistically throws QuotaExceededError (or SecurityError when storage is disabled) — and since saveSignature runs inside a synchronous click handler with no error boundary, the throw escapes uncaught and the insert silently aborts. Wrap it like readJSON and surface a “couldn't save (storage full)” message.
| } | ||
| const reader = new FileReader() | ||
| reader.onload = () => onChange(typeof reader.result === 'string' ? reader.result : null) | ||
| reader.readAsDataURL(file) |
There was a problem hiding this comment.
No reader.onerror — if the read fails (unreadable/corrupt file, IO error) onChange never fires, the Insert button stays disabled, and the user gets no feedback (the size-limit branch just above does alert). Add an onerror that clears state and surfaces the failure.
| const undo = useCallback(async () => { | ||
| if (!instance) return | ||
| try { | ||
| await instance.history.undo() |
There was a problem hiding this comment.
This undo (and the deleteAll handler) catch to console.warn only — the user clicks Undo/Delete, it fails, nothing happens, no indication why. TextToolbar mirrors this. Reuse the operationError pattern from Toolbar so these mutating actions report failure.
| await instance.create([widget, formField]) | ||
| instance.setSelectedAnnotations?.(annotationIdList([widget.id])) | ||
| } catch (err) { | ||
| console.error('Could not place field', err) |
There was a problem hiding this comment.
This create() failure is console.error-only, but sibling paths in this same function window.alert on failure — so a field dropped off-page (or a license/validation rejection) just silently doesn't appear. Make the feedback consistent.
| className="app-header-btn app-header-btn-outline" | ||
| > | ||
| Learn More | ||
| Document Authoring SDK |
There was a problem hiding this comment.
Is this change (plus the SVG <title> additions under document-authoring/ai-editing/) intentionally part of this PR? It's unrelated to the web-sdk-demo and isn't mentioned in the description — looks like it may have been swept in by a repo-wide format/lint run. Consider splitting it out.
What's special about this demo
Most existing Web SDK examples in this repo show one feature: a single custom button, a single custom tooltip, a single replacement slot. They live inside the SDK's chrome.
This demo does the opposite — it hides the SDK's chrome and rebuilds a complete document workspace around the viewer, in the style of a host application like Dropbox's PDF UI. The viewer is just a content surface; the toolbar, file explorer, signing flow, form-creator panel, and drawing/text tools are all host-app code talking to the SDK through its instance API.
That's the angle this example covers that no other example in the repo does: it stress-tests the app/viewer boundary end-to-end and shows that customer-owned UI around the SDK is a viable path, not just per-component customization.
The six integration patterns it demonstrates
The API surfaces it exercises across the boundary
Slot replacement: `ui.tools.main`, `ui.tools.contextual`, `ui.signatures.create`, `ui.signatures.list`, `ui.formCreator.propertyEditor`, `annotationTooltipCallback`.
Instance methods: `setUI`, `setViewState`, `applyOperations`, `history.undo`/`redo`, `setAnnotationPresets`, `setCurrentAnnotationPreset`, `contentDocument`, `transformContentClientToPageSpace`, `createAttachment`, `create`, `update`, `delete`, `getSelectedAnnotations`, `setSelectedAnnotations`, `getFormFields`, `getAnnotations`, `exportPDF`, `InteractionMode.*`.
Annotation/form model: `Annotations.WidgetAnnotation`, `Annotations.ImageAnnotation` (with `isSignature: true`), `FormFields.TextFormField`, `FormFields.CheckBoxFormField`, `FormOption`, `Geometry.Rect`, `Geometry.Point`.
This breadth across one app is the point — it shows that all the pieces a customer needs to build a full host-app shell are present and that they compose.
Why this matters
This example was originally built as a validation example for the headless / API-first strategy: prove that the SDK is usable as an embeddable viewer surface inside a customer-owned UI, not only as a closed UI box that can be poked at the edges. As a side benefit, the demo surfaced an API ergonomics gap in slot callbacks (which then drove a follow-up SDK change) — exactly the kind of feedback this category of example is meant to produce.
What's rebuilt vs. consumed
Notes
Test plan